| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- 'use client';
- import { useState, useEffect, useCallback, useRef } from 'react';
- import * as signalR from '@microsoft/signalr';
- import { fetchApi } from '@/lib/utils/client';
- import { useStudioContext } from '@/app/studio/context';
- import { useSignalRContext } from '@/contexts/signalrProvider';
- import type { CrewMemberItem, CrewMemberListResponse, SearchResultItem, SearchMemberResponse } from '@/types/response/crew/member';
- import { Button } from '@/components/ui/button';
- import { Input } from '@/components/ui/input';
- import { Label } from '@/components/ui/label';
- import { Checkbox } from '@/components/ui/checkbox';
- import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
- import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
- import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
- import { Info } from 'lucide-react';
- function RequiredLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
- return <Label htmlFor={htmlFor}><span className="text-destructive mr-0.5">*</span>{children}</Label>;
- }
- const EMPTY_MEMBER_FORM = {
- nickname: '',
- role: '',
- sortOrder: 0,
- isActive: true
- };
- type Props = {
- crewID: number;
- };
- export default function CrewMembersTab({ crewID }: Props)
- {
- const { channelID } = useStudioContext();
- const { appConnection, appConnected } = useSignalRContext();
- const [members, setMembers] = useState<CrewMemberItem[]>([]);
- const [loading, setLoading] = useState(true);
- // 초대 코드
- const [inviteCode, setInviteCode] = useState<string|null>(null);
- const [generatingCode, setGeneratingCode] = useState(false);
- // 회원 검색
- const [searchQuery, setSearchQuery] = useState('');
- const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
- const [showSearch, setShowSearch] = useState(false);
- const searchTimer = useRef<ReturnType<typeof setTimeout>|null>(null);
- // 크루원 편집 모달
- const [editModal, setEditModal] = useState<{ open: boolean; member: CrewMemberItem|null }>({ open: false, member: null });
- const [editForm, setEditForm] = useState(EMPTY_MEMBER_FORM);
- const [saving, setSaving] = useState(false);
- const fetchMembers = useCallback(async () => {
- try {
- const res = await fetchApi<CrewMemberListResponse>(`/api/studio/crew/member/list/${crewID}`);
- setMembers(res.data?.list ?? []);
- } catch {
- } finally {
- setLoading(false);
- }
- }, [crewID]);
- useEffect(() => { fetchMembers(); }, [fetchMembers]);
- // SignalR 멤버 변경 이벤트 수신 — 운영자 그룹(crew:{crewID}) 구독 + 200ms debounce refetch.
- // 다중 이벤트(예: 일괄 작업) 시 한 번만 refetch 하도록 합침.
- useEffect(() => {
- if (!appConnection || !appConnected) {
- return;
- }
- if (appConnection.state !== signalR.HubConnectionState.Connected) {
- return;
- }
- let refetchTimer: ReturnType<typeof setTimeout>|null = null;
- const scheduleRefetch = () => {
- if (refetchTimer) {
- clearTimeout(refetchTimer);
- }
- refetchTimer = setTimeout(() => {
- fetchMembers();
- }, 200);
- };
- const handler = (_payload: unknown) => {
- scheduleRefetch();
- };
- appConnection.on('ReceiveCrewMemberChange', handler);
- appConnection.invoke('JoinCrew', crewID).catch(err => {
- console.warn('[crew] JoinCrew failed:', err);
- });
- return () => {
- if (refetchTimer) {
- clearTimeout(refetchTimer);
- }
- appConnection.off('ReceiveCrewMemberChange', handler);
- if (appConnection.state === signalR.HubConnectionState.Connected) {
- appConnection.invoke('LeaveCrew', crewID).catch(() => {});
- }
- };
- }, [appConnection, appConnected, crewID, fetchMembers]);
- // SignalR presence 구독 — 멤버 목록 변경 시 재구독, 이벤트 수신 시 isOnline 갱신.
- // members 가 빈 배열일 때는 구독할 대상이 없어 early return — 첫 fetchMembers 완료 후 의존성 변화로 재실행되어 정상 구독.
- const memberIDsKey = members.map(m => m.memberID).join(',');
- useEffect(() => {
- if (!appConnection || !appConnected) {
- return;
- }
- if (appConnection.state !== signalR.HubConnectionState.Connected) {
- return;
- }
- const memberIDs = Array.from(new Set(members.map(m => m.memberID))).filter(id => id > 0);
- if (memberIDs.length === 0) {
- return;
- }
- const handler = (memberID: number, isOnline: boolean) => {
- setMembers(prev => prev.map(m => m.memberID === memberID ? { ...m, isOnline } : m));
- };
- appConnection.on('ReceivePresenceChange', handler);
- appConnection.invoke('SubscribePresence', memberIDs).catch(err => {
- console.warn('[presence] SubscribePresence failed:', err);
- });
- return () => {
- appConnection.off('ReceivePresenceChange', handler);
- if (appConnection.state === signalR.HubConnectionState.Connected) {
- appConnection.invoke('UnsubscribePresence', memberIDs).catch(() => {});
- }
- };
- // memberIDsKey 가 동일하면 effect 재실행 안됨 (불필요한 sub/unsub 방지).
- // members 자체를 의존성에 넣지 않는 이유: members 가 같은 ID 집합이어도 객체 reference 가 바뀌면 매번 재실행되므로.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [appConnection, appConnected, memberIDsKey]);
- // 회원 검색 (디바운스)
- useEffect(() => {
- if (searchTimer.current) {
- clearTimeout(searchTimer.current);
- }
- if (searchQuery.trim().length < 2) {
- setSearchResults([]);
- return;
- }
- searchTimer.current = setTimeout(async () => {
- try {
- const res = await fetchApi<SearchMemberResponse>(
- `/api/studio/crew/member/search?channelID=${channelID}&q=${encodeURIComponent(searchQuery)}&crewID=${crewID}`
- );
- setSearchResults(res.data?.list ?? []);
- } catch {}
- }, 300);
- return () => {
- if (searchTimer.current) {
- clearTimeout(searchTimer.current);
- }
- };
- }, [searchQuery, channelID, crewID]);
- const handleAddMember = async (item: SearchResultItem) => {
- try {
- await fetchApi('/api/studio/crew/member/add', {
- method: 'POST',
- body: {
- crewID,
- targetMemberID: item.memberID,
- nickname: item.channelName ?? item.name ?? item.email,
- sortOrder: members.length + 1
- }
- });
- setSearchQuery('');
- setSearchResults([]);
- setShowSearch(false);
- fetchMembers();
- } catch (err: unknown) {
- alert(err instanceof Error ? err.message : '추가에 실패했습니다.');
- }
- };
- const handleRemove = async (memberID: number) => {
- if (!confirm('이 크루원을 삭제하시겠습니까?')) {
- return;
- }
- try {
- await fetchApi(`/api/studio/crew/member/${memberID}`, { method: 'DELETE' });
- fetchMembers();
- } catch (err: unknown) {
- alert(err instanceof Error ? err.message : '삭제에 실패했습니다.');
- }
- };
- const openEdit = (member: CrewMemberItem) => {
- setEditForm({ nickname: member.nickname, role: member.role ?? '', sortOrder: member.sortOrder, isActive: member.isActive });
- setEditModal({ open: true, member });
- };
- const handleUpdate = async () => {
- if (!editModal.member) {
- return;
- }
- setSaving(true);
- try {
- await fetchApi(`/api/studio/crew/member/${editModal.member.id}`, {
- method: 'PUT',
- body: {
- crewMemberID: editModal.member.id,
- nickname: editForm.nickname,
- role: editForm.role || null,
- sortOrder: editForm.sortOrder,
- isActive: editForm.isActive
- }
- });
- setEditModal({ open: false, member: null });
- fetchMembers();
- } catch (err: unknown) {
- alert(err instanceof Error ? err.message : '수정에 실패했습니다.');
- } finally {
- setSaving(false);
- }
- };
- const handleGenerateCode = async () => {
- setGeneratingCode(true);
- try {
- const res = await fetchApi<{ inviteCode: string }>('/api/studio/crew/invite/generate', {
- method: 'POST',
- body: { crewID }
- });
- setInviteCode(res.data?.inviteCode ?? null);
- } catch (err: unknown) {
- alert(err instanceof Error ? err.message : '코드 생성에 실패했습니다.');
- } finally {
- setGeneratingCode(false);
- }
- };
- const copyCode = () => {
- if (inviteCode) {
- navigator.clipboard.writeText(inviteCode);
- alert('복사되었습니다.');
- }
- };
- if (loading) return <p className="studio-page__empty">준비 중...</p>;
- return (
- <>
- {/* 초대 코드 */}
- <fieldset className="crew-invite">
- <legend className="crew-invite__legend">
- <span>초대 코드</span>
- <TooltipProvider delay={0}>
- <Tooltip>
- <TooltipTrigger
- render={
- <button type="button" className="crew-invite__help" aria-label="초대 코드 안내">
- <Info size={14} aria-hidden="true" />
- </button>
- }
- />
- <TooltipContent side="top" align="start">
- 이 코드를 입력한 회원은 크루 방송 합류에 동의하는 것으로 처리됩니다.<br />
- 입력 페이지: /crew/join
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </legend>
- <div className="crew-invite__body">
- <Input
- readOnly
- value={inviteCode ?? ''}
- placeholder="코드를 생성해 주세요"
- className="crew-invite__input"
- />
- <div className="crew-invite__actions">
- {inviteCode ? (
- <>
- <Button type="button" variant="outline" onClick={copyCode}>복사</Button>
- <Button type="button" variant="outline" onClick={handleGenerateCode} disabled={generatingCode}>
- {generatingCode ? '생성 중...' : '재생성'}
- </Button>
- </>
- ) : (
- <Button type="button" onClick={handleGenerateCode} disabled={generatingCode}>
- {generatingCode ? '생성 중...' : '코드 생성'}
- </Button>
- )}
- </div>
- </div>
- </fieldset>
- {/* 크루원 관리 */}
- <div className="crew-members">
- <div className="crew-members__toolbar pb-3">
- <h2 className="crew-members__subtitle">크루원 ({members.length}명)</h2>
- <Button size="sm" onClick={() => setShowSearch(!showSearch)}>
- {showSearch ? '닫기' : '+ 크루원 추가'}
- </Button>
- </div>
- {showSearch && (
- <div className="member-search mb-4">
- <Input className="member-search__input" placeholder="채널명, 핸들, 이메일로 검색 (2자 이상)" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} autoComplete='off'/>
- {searchResults.length > 0 && (
- <div className="member-search__results">
- {searchResults.map(item => (
- <button type="button" key={item.memberID} className="member-search__item" onClick={() => handleAddMember(item)}>
- {item.channelThumb ? <img src={item.channelThumb} alt="" className="member-search__thumb" /> : <div className="member-search__thumb" />}
- <div className="member-search__info">
- <div className="member-search__name">{item.channelName ?? item.name ?? '(이름 없음)'}</div>
- <div className="member-search__email">{item.email}</div>
- {item.channelHandle && (
- <div className="member-search__handle">@{item.channelHandle}</div>
- )}
- </div>
- </button>
- ))}
- </div>
- )}
- </div>
- )}
- <div className="studio-page__table-wrap">
- <table className="studio-page__table">
- <thead>
- <tr>
- <th>순서</th>
- <th>별명</th>
- <th>역할</th>
- <th>채널</th>
- <th>접속</th>
- <th>상태</th>
- <th>가입일</th>
- <th>작업</th>
- </tr>
- </thead>
- <tbody>
- {members.length === 0 ? (
- <tr><td colSpan={8} className="studio-page__empty">등록된 크루원이 없습니다.</td></tr>
- ) : members.map(m => {
- const isAccepted = m.inviteStatus === 'Accepted';
- const statusLabel = m.inviteStatus === 'Accepted'
- ? (m.isActive ? '활성' : '비활성')
- : m.inviteStatus === 'Pending' ? '초대 중'
- : m.inviteStatus === 'Declined' ? '거절됨'
- : '만료';
- const statusModifier = m.inviteStatus === 'Accepted'
- ? (m.isActive ? 'active' : 'inactive')
- : m.inviteStatus === 'Pending' ? 'pending'
- : 'expired';
- return (
- <tr key={m.id}>
- <td>{m.sortOrder}</td>
- <td>
- <Avatar className="member-row__thumb">
- <AvatarImage src={(m.channelThumb ?? m.thumb) || undefined} alt={m.nickname} />
- <AvatarFallback className="member-row__thumb-fallback p-1">
- <img src="/icons/layout/avatar.svg" alt={m.nickname} />
- </AvatarFallback>
- </Avatar>
- {m.nickname}
- </td>
- <td>{m.role ?? '-'}</td>
- <td>{m.channelName ?? '-'}</td>
- <td>
- <span
- className={`member-row__online-dot${m.isOnline ? ' member-row__online-dot--on' : ''}`}
- title={m.isOnline ? '온라인' : '오프라인'}
- aria-label={m.isOnline ? '온라인' : '오프라인'}
- />
- </td>
- <td><span className={`studio-page__badge studio-page__badge--${statusModifier}`}>{statusLabel}</span></td>
- <td>{new Date(m.joinedAt).toLocaleDateString('ko-KR')}</td>
- <td>
- <div className="studio-page__actions">
- {isAccepted ? (
- <>
- <Button variant="outline" size="sm" onClick={() => openEdit(m)}>수정</Button>
- <Button variant="destructive" size="sm" onClick={() => handleRemove(m.id)}>삭제</Button>
- </>
- ) : (
- <span className="text-xs text-muted-foreground">{m.inviteStatus === 'Pending' ? '응답 대기' : '-'}</span>
- )}
- </div>
- </td>
- </tr>
- );
- })}
- </tbody>
- </table>
- </div>
- </div>
- {/* 크루원 편집 모달 */}
- <Dialog open={editModal.open} onOpenChange={open => { if (!open) setEditModal({ open: false, member: null }); }}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>크루원 수정</DialogTitle>
- </DialogHeader>
- <div className="space-y-4">
- <div className="space-y-2">
- <RequiredLabel htmlFor="member-nickname">별명</RequiredLabel>
- <Input id="member-nickname" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
- </div>
- <div className="space-y-2">
- <Label htmlFor="member-role">역할</Label>
- <Input id="member-role" value={editForm.role} onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))} placeholder="예: 게이머, MC" />
- </div>
- <div className="space-y-2">
- <Label htmlFor="member-order">순서</Label>
- <Input id="member-order" type="number" min={0} value={editForm.sortOrder} onChange={e => setEditForm(f => ({ ...f, sortOrder: Number(e.target.value) }))} />
- </div>
- <div className="flex items-center gap-2">
- <Checkbox id="member-active" checked={editForm.isActive} onCheckedChange={v => setEditForm(f => ({ ...f, isActive: !!v }))} />
- <Label htmlFor="member-active">활성화</Label>
- </div>
- </div>
- <DialogFooter className="flex flex-row justify-center gap-2 sm:justify-center">
- <Button variant="outline" className="flex-1 sm:flex-none" onClick={() => setEditModal({ open: false, member: null })}>취소</Button>
- <Button className="flex-1 sm:flex-none" onClick={handleUpdate} disabled={saving}>{saving ? '저장 중...' : '저장'}</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </>
- );
- }
|